🎖️GitЯра🎖️
Commit a0484e7707d933b17eb22aad98056efea24d2eb2
Parents : 8403fc5
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-07-16T13:06:51-05:00
Committer : GitHub <noreply@github.com>
Date : 2026-07-16T13:06:51-05:00
refactor(map): adopt maps-compose 8.4.0 stock clustering, drop custom renderer workaround (#6301)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Changes
4 files changed, 63 insertions(+), 129 deletions(-)
Diff
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
index b1ae7a07a1..c5a8d751ed 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
@@ -1348,7 +1348,14 @@ private fun Layer.safeRemoveLayerFromMap() {
private fun Layer.safeAddLayerToMap() {
try {
- if (!isLayerOnMap) addLayerToMap()
+ // maps-utils 5.0.0 dropped isLayerOnMap() from the Layer base class; both concrete layers still expose it.
+ val isOnMap =
+ when (this) {
+ is GeoJsonLayer -> isLayerOnMap()
+ is KmlLayer -> isLayerOnMap()
+ else -> false
+ }
+ if (!isOnMap) addLayerToMap()
} catch (e: Exception) {
Logger.withTag("MapView").e(e) { "Error adding map layer" }
}
@@ -1368,15 +1375,15 @@ private fun GeoJsonLayer.applySimpleStyleSpec() {
val stroke = feature.cssColor("stroke") ?: feature.cssColor("color")
val fillOpacity = feature.getProperty("fill-opacity")?.toFloatOrNull()
val strokeWidth = feature.getProperty("stroke-width")?.toFloatOrNull() ?: DEFAULT_GEOJSON_STROKE_WIDTH
- when (feature.geometry?.geometryType) {
+ when (feature.getGeometry()?.getGeometryType()) {
"Polygon",
"MultiPolygon",
->
feature.polygonStyle =
GeoJsonPolygonStyle().apply {
fill?.let { fillColor = it.resolveFillAlpha(fillOpacity) }
- stroke?.let { strokeColor = it }
- this.strokeWidth = strokeWidth
+ stroke?.let { setStrokeColor(it) }
+ setStrokeWidth(strokeWidth)
}
"LineString",
@@ -1385,7 +1392,7 @@ private fun GeoJsonLayer.applySimpleStyleSpec() {
feature.lineStringStyle =
GeoJsonLineStringStyle().apply {
stroke?.let { color = it }
- width = strokeWidth
+ setWidth(strokeWidth)
}
else -> Unit // Points keep the default marker.
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt
index 0e3f872be2..30fd33bb3c 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt
@@ -16,51 +16,33 @@
*/
package org.meshtastic.app.map.component
-import android.content.Context
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.SideEffect
-import androidx.compose.runtime.key
-import androidx.compose.runtime.mutableStateMapOf
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.platform.LocalContext
-import com.google.android.gms.maps.GoogleMap
-import com.google.android.gms.maps.model.BitmapDescriptor
-import com.google.android.gms.maps.model.Marker
-import com.google.android.gms.maps.model.MarkerOptions
import com.google.maps.android.clustering.Cluster
-import com.google.maps.android.clustering.ClusterManager
import com.google.maps.android.clustering.view.DefaultClusterRenderer
import com.google.maps.android.compose.Circle
-import com.google.maps.android.compose.MapEffect
import com.google.maps.android.compose.MapsComposeExperimentalApi
import com.google.maps.android.compose.clustering.Clustering
-import com.google.maps.android.compose.clustering.rememberClusterManager
-import com.google.maps.android.compose.rememberComposeBitmapDescriptor
+import com.google.maps.android.compose.clustering.ClusteringMarkerProperties
import org.meshtastic.app.map.model.NodeClusterItem
import org.meshtastic.feature.map.BaseMapViewModel
private const val MIN_CLUSTER_SIZE = 10
/**
- * Renders node markers with clustering.
+ * Renders node markers with clustering via the library's [Clustering] composable.
*
- * Marker bitmaps are generated **in the maps compose scope** via [rememberComposeBitmapDescriptor], which composes each
- * chip in a `ComposeView` parented to the live host view (a real, attached view that has valid `ViewTreeLifecycleOwner`
- * / `SavedStateRegistryOwner`) and renders it synchronously to a [BitmapDescriptor].
+ * Each unclustered node is composed as a [PulsingNodeChip] (`clusterItemContent`), with its z-index forwarded through
+ * [ClusteringMarkerProperties]; cluster bubbles keep the library's default rendering. Native info windows
+ * (title/snippet from [NodeClusterItem]) and click interactions are handled by the library renderer, and precision
+ * circles are drawn for the currently-unclustered items via `clusterItemDecoration`.
*
- * This deliberately avoids the clustering library's `clusterItemContent` path: that renderer
- * ([com.google.maps.android.compose.clustering.ComposeUiClusterRenderer]) composes each item in a *detached*
- * `ComposeView` that only carries a fake lifecycle owner and no `SavedStateRegistryOwner`, so it crashes when the
- * surrounding Navigation 3 / popup hierarchy lacks those owners (the top Crashlytics FATAL). A custom
- * [DefaultClusterRenderer] subclass assigns the pre-baked bitmaps instead, keeping native info windows (title/snippet
- * from [NodeClusterItem]) and click interactions intact.
+ * Requires maps-compose >= 8.4.0: earlier versions composed cluster items into a detached `ComposeView` that carried no
+ * lifecycle/saved-state owners, crashing under the Navigation 3 hierarchy (fixed upstream in
+ * googlemaps/android-maps-compose#930, which this file previously worked around with a custom [DefaultClusterRenderer]
+ * assigning pre-baked bitmaps).
*/
@OptIn(MapsComposeExperimentalApi::class)
-@Suppress("NestedBlockDepth")
@Composable
fun NodeClusterMarkers(
nodeClusterItems: List<NodeClusterItem>,
@@ -68,48 +50,16 @@ fun NodeClusterMarkers(
navigateToNodeDetails: (Int) -> Unit,
onClusterClick: (Cluster<NodeClusterItem>) -> Boolean,
) {
- val context = LocalContext.current
- val clusterManager = rememberClusterManager<NodeClusterItem>()
-
- // Bake each node's marker icon in-scope. Keyed by node so a bitmap is only re-rendered when that node
- // actually changes. The descriptors are stashed in a snapshot map the renderer reads at render time.
- val iconDescriptors = remember { mutableStateMapOf<Int, BitmapDescriptor>() }
- nodeClusterItems.forEach { item ->
- key(item.node.num) {
- val descriptor = rememberComposeBitmapDescriptor(item.node) { PulsingNodeChip(node = item.node) }
- DisposableEffect(descriptor) {
- iconDescriptors[item.node.num] = descriptor
- onDispose { iconDescriptors.remove(item.node.num) }
- }
- }
- }
-
- if (clusterManager != null) {
- val rendererState = remember { mutableStateOf<NodeClusterRenderer?>(null) }
-
- // The renderer needs the GoogleMap instance, only available inside the map scope.
- MapEffect(clusterManager) { map ->
- val renderer = NodeClusterRenderer(context, map, clusterManager) { iconDescriptors[it] }
- clusterManager.renderer = renderer
- rendererState.value = renderer
- }
-
- // Keep listeners current — the lambdas can change across recompositions.
- SideEffect {
- clusterManager.setOnClusterClickListener { cluster -> onClusterClick(cluster) }
- clusterManager.setOnClusterItemInfoWindowClickListener { item -> navigateToNodeDetails(item.node.num) }
- }
-
- // Re-cluster once the renderer is attached and freshly-baked icons arrive, so markers pick up the bitmaps
- // even when the cluster manager became available after the icons were rendered.
- val renderer = rendererState.value
- LaunchedEffect(renderer, iconDescriptors.size) { if (renderer != null) clusterManager.cluster() }
-
- Clustering(items = nodeClusterItems, clusterManager = clusterManager)
-
- // Precision circles for the currently-unclustered items (the renderer tracks them as the zoom changes).
- if (mapFilterState.showPrecisionCircle) {
- renderer?.unclusteredItems?.value?.forEach { item ->
+ Clustering(
+ items = nodeClusterItems,
+ onClusterClick = onClusterClick,
+ onClusterItemInfoWindowClick = { item -> navigateToNodeDetails(item.node.num) },
+ clusterItemContent = { item ->
+ ClusteringMarkerProperties(zIndex = item.zIndex)
+ PulsingNodeChip(node = item.node)
+ },
+ clusterItemDecoration = { item ->
+ if (mapFilterState.showPrecisionCircle) {
item.getPrecisionMeters()?.let { precisionMeters ->
if (precisionMeters > 0) {
Circle(
@@ -123,44 +73,14 @@ fun NodeClusterMarkers(
}
}
}
- }
- }
-}
-
-/**
- * [DefaultClusterRenderer] that assigns the pre-baked [BitmapDescriptor]s (rendered in the maps compose scope) to
- * non-clustered item markers, and exposes the set of currently-unclustered items so the caller can decorate them (e.g.
- * precision circles). Cluster bubbles keep the library's default rendering.
- */
-private class NodeClusterRenderer(
- context: Context,
- map: GoogleMap,
- clusterManager: ClusterManager<NodeClusterItem>,
- private val iconProvider: (Int) -> BitmapDescriptor?,
-) : DefaultClusterRenderer<NodeClusterItem>(context, map, clusterManager) {
-
- val unclusteredItems = mutableStateOf<Set<NodeClusterItem>>(emptySet())
-
- init {
- minClusterSize = MIN_CLUSTER_SIZE
- }
-
- override fun onClustersChanged(clusters: Set<Cluster<NodeClusterItem>>) {
- super.onClustersChanged(clusters)
- unclusteredItems.value = clusters.filterNot { shouldRenderAsCluster(it) }.flatMap { it.items }.toSet()
- }
-
- override fun onBeforeClusterItemRendered(item: NodeClusterItem, markerOptions: MarkerOptions) {
- // super sets title/snippet from the ClusterItem, which drives the native info window.
- super.onBeforeClusterItemRendered(item, markerOptions)
- iconProvider(item.node.num)?.let { markerOptions.icon(it) }
- markerOptions.zIndex(item.getZIndex())
- }
-
- override fun onClusterItemUpdated(item: NodeClusterItem, marker: Marker) {
- // super keeps title/snippet (and the open info window) in sync.
- super.onClusterItemUpdated(item, marker)
- iconProvider(item.node.num)?.let { marker.setIcon(it) }
- marker.zIndex = item.getZIndex()
- }
+ },
+ onClusterManager = { clusterManager ->
+ // The library renderer extends DefaultClusterRenderer; raise its clustering threshold once.
+ val renderer = clusterManager.renderer as? DefaultClusterRenderer<*>
+ if (renderer != null && renderer.minClusterSize != MIN_CLUSTER_SIZE) {
+ renderer.minClusterSize = MIN_CLUSTER_SIZE
+ clusterManager.cluster()
+ }
+ },
+ )
}
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/model/NodeClusterItem.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/model/NodeClusterItem.kt
index c43dff841d..5b9a293202 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/model/NodeClusterItem.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/model/NodeClusterItem.kt
@@ -27,24 +27,32 @@ data class NodeClusterItem(
val nodeSnippet: String,
val myNodeNum: Int? = null,
) : ClusterItem {
- override fun getPosition(): LatLng = nodePosition
+ override val position: LatLng
+ get() = nodePosition
- override fun getTitle(): String = nodeTitle
+ override val title: String
+ get() = nodeTitle
- override fun getSnippet(): String = nodeSnippet
+ override val snippet: String
+ get() = nodeSnippet
- override fun getZIndex(): Float = when {
- node.num == myNodeNum -> 5.0f
+ override val zIndex: Float
+ get() =
+ when {
+ node.num == myNodeNum -> 5.0f
- // My node is always highest
- node.isFavorite -> 5.0f
+ // My node is always highest
+ node.isFavorite -> 5.0f
- // Favorites are equally high priority
- else -> 4.0f
- }
+ // Favorites are equally high priority
+ else -> 4.0f
+ }
+
+ fun getPrecisionMeters(): Double? = PRECISION_METERS_BY_BITS[node.position.precision_bits]
- fun getPrecisionMeters(): Double? {
- val precisionMap =
+ companion object {
+ // Allocated once: this lookup runs for every unclustered item on each cluster pass.
+ private val PRECISION_METERS_BY_BITS =
mapOf(
10 to 23345.484932,
11 to 11672.7369,
@@ -57,6 +65,5 @@ data class NodeClusterItem(
18 to 91.182212,
19 to 45.58554,
)
- return precisionMap[this.node.position.precision_bits]
}
}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 40864b26d8..7d20f44c6f 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -62,7 +62,7 @@ androidx-compose-bom-aligned = "1.11.4"
jetbrains-adaptive = "1.3.0-beta02"
# Google
-maps-compose = "8.3.1"
+maps-compose = "8.4.0"
# ML Kit
mlkit-barcode-scanning = "17.3.0"
Served by rngit 1.5.2 - Generated in 0.12s